fix(loader-overlay)!: errors following a full review of the component (#DS-5482) - #1982
fix(loader-overlay)!: errors following a full review of the component (#DS-5482)#1982artembelik wants to merge 2 commits into
Conversation
`text` and `caption` were the two inputs the automated signal migration skipped — it saw them read inside `@if` blocks and would not risk the narrowing. They are `input()` now, and honest about being optional: both were declared `string` over a field with no initializer, so an overlay that bound neither reported `undefined` from a non-nullable type. `transparent` gained `booleanAttribute`. `<kbq-loader-overlay transparent>` used to pass the empty string, which is falsy, so the valueless attribute rendered the filled background — the opposite of how it reads. Everything the template uses to choose between a projected slot and an input — `isEmpty`, `isExternalIndicator`, `isExternalText`, `isExternalCaption`, `spinnerSize` and the three content queries — left the public surface. What the overlay renders is the contract, not how it decides. BREAKING CHANGE: `KbqLoaderOverlay.text` and `caption` are signal inputs reporting `string | undefined`; `transparent` is a `booleanAttribute` input, so a valueless attribute now means true; the template helpers and content queries are protected or private. Reported and partly rewritten by the `loader-overlay-signals` schematic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Visit the preview URL for this PR (updated for commit 47fd675): https://koobiq-next--prs-1982-bsqilact.web.app (expires Sat, 05 Sep 2026 15:05:29 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c |
lskramarov
left a comment
There was a problem hiding this comment.
Automated review of the loader-overlay signals rework and the new loader-overlay-signals schematic. 15 findings, most severe first; each is anchored inline. The component change itself is sound — almost everything below is in the migration schematic, plus one CI gate.
Two that block CI / make the migration a no-op:
fixisundefinedunderng update, so the schematic rewrites nothing.tools/check-public-api-any/baseline.jsonstill records"loader-overlay": 1; thebooleanAttributetransform makes it 2, and.github/workflows/api.ymlruns that check on every PR.
Everything else is inline.
|
|
||
| export default function loaderOverlaySignals(options: Schema): Rule { | ||
| return async (tree: Tree, context: SchematicContext) => { | ||
| const { project, fix } = options; |
There was a problem hiding this comment.
The auto-fix never runs under ng update — fix arrives undefined.
migrations.json declares no "schema" for any entry (0 occurrences in the file), and the CLI invokes each migration with no options at all — @angular/cli/src/commands/update/cli.js:329 calls this.executeSchematic(workflow, migration.collection.name, migration.name) against the signature executeSchematic(workflow, collection, schematic, options = {}). So schema.json's "fix": { "default": true } never reaches the rule.
A consumer runs ng update @koobiq/components@20, every overlay.text / overlay.caption read stays unmigrated, and the log fills with [loader-overlay-signals] would update <file> (run with --fix to apply). Meanwhile README.md:3 ("invoked automatically by ng update") and docs/guides/migration.en.md ("the text and caption reads are rewritten") promise the opposite.
12 of the 18 sibling migrations already guard this, and app-switcher-signals/index.ts:412 spells out why:
// `ng update` invokes migrations with no options at all, and migrations.json declares no schema, so the
// schema default never reaches us — applying the fix is the intended behaviour there.
const fix = options.fix ?? true;index.spec.ts:37 is async function run(fix: boolean = true) and always passes fix explicitly, so no test covers the ng update call shape.
| const { project, fix } = options; | |
| const { project } = options; | |
| const fix = options.fix ?? true; |
| readonly size: _angular_core.InputSignal<KbqDefaultSizes>; | ||
| protected readonly spinnerSize: _angular_core.Signal<ProgressSpinnerSize>; | ||
| readonly text: _angular_core.InputSignal<string | undefined>; | ||
| readonly transparent: _angular_core.InputSignalWithTransform<boolean, unknown>; |
There was a problem hiding this comment.
yarn run check-public-api-any fails on this line — the baseline was not bumped.
The booleanAttribute transform turns transparent into InputSignalWithTransform<boolean, unknown>, a second unknown in the hand-written surface. tools/check-public-api-any/baseline.json still records "loader-overlay": 1.
I ran the tool's own filter (tools/check-public-api-any/index.ts:31-35 — \b(any|unknown)\b on non-generated, non-comment lines) over both revisions:
| revision | count |
|---|---|
base 4bb1c9927 |
1 (card) |
| this branch | 2 (card line 18, transparent line 32) |
.github/workflows/api.yml:20 runs check-public-api-any on every pull_request, so the API job exits 1 with The published type surface gained \any` / `unknown`: loader-overlay: 1 → 2`.
yarn run approve-public-api-any|
|
||
| for (const ref of refs) { | ||
| // `\bref\.(member)\b(?!\s*\()` — skip anything already invoked, so the rewrite is idempotent. | ||
| const pattern = new RegExp(`\\b(${escapeRegExp(ref)})\\.(${members})\\b(?!\\s*\\()`, 'g'); |
There was a problem hiding this comment.
The template rewrite is a raw String.replace over the whole file, so it corrupts unrelated .text / .caption reads.
The parse is used only to discover ref names; the replacement then ignores the AST entirely. And \b matches after a ., so the comment above ("scoped to those exact identifiers") is not true — any path whose last segment equals the ref name matches.
I ran this exact regex in node against a template declaring <kbq-loader-overlay #overlay />. Every one of these was mutated and written to disk under --fix:
| input | output |
|---|---|
@for (overlay of overlays; track overlay.id) { {{ overlay.text }} } |
{{ overlay.text() }} — TypeError: overlay.text is not a function |
{{ vm.overlay.text }} |
{{ vm.overlay.text() }} — unrelated view-model |
<p>Set overlay.text to change the label.</p> |
overlay.text() in the rendered copy |
<!-- overlay.text is the input --> |
<!-- overlay.text() is the input --> |
<a href="/api#overlay.text"> |
broken link |
<span data-doc="overlay.text"> |
corrupted static attribute |
SIGNAL_MEMBERS here is ['text', 'caption'] — two of the most common property names in any codebase — which makes this far likelier than for alert-signals' compact / alertStyle. Anchoring with (?<![.\w$]) before the ref would kill the vm.overlay.text case; the loop-variable and text-node cases need the rewrite to walk binding expressions rather than the raw string.
| if ( | ||
| ts.isBinaryExpression(parent) && | ||
| parent.left === node && | ||
| parent.operatorToken.kind === ts.SyntaxKind.EqualsToken |
There was a problem hiding this comment.
Compound assignments are rewritten into code that does not parse.
Only EqualsToken is treated as a write; every other assignment form falls through to the read branch on line 168 and gets () appended to the left-hand side:
| source | after migration |
|---|---|
overlay.text += ' (retrying)' |
overlay.text() += ' (retrying)' |
overlay.caption ??= 'Loading' |
overlay.caption() ??= 'Loading' |
overlay.text++ (PostfixUnaryExpression parent — not checked at all) |
overlay.text()++ |
All three are TS2364 / Invalid left-hand side in assignment. The plain = case is deliberately left alone so the consumer gets a clean read-only error; the compound case turns a type error into a file the compiler cannot parse, at a line the schematic itself wrote. index.spec.ts:124 covers only the plain = form.
Widening the guard to ts.isAssignmentExpression(parent) (or just parent.left === node for any binary assignment operator) plus a PrefixUnaryExpression/PostfixUnaryExpression check would return them all to "leave alone".
Separately, the comment three lines down still reads // Read (incl. optional chain `x?.compact`): append `()`. — compact is a KbqAlert member carried over from alert-signals; every other sibling adapted the example to its own member.
| export const warnPatterns: WarnPattern[] = [ | ||
| { | ||
| anchor: '\\bKbqLoaderOverlay\\b', | ||
| pattern: '(?:viewChild|ViewChild|contentChild|ContentChild)[^\\n;]*\\bKbqLoaderOverlay\\b', |
There was a problem hiding this comment.
This warning fires on the form the migration already fixed, and misses the form it is actually about.
I ran the pattern in node:
| input | matches |
|---|---|
@ViewChild(KbqLoaderOverlay) loaderOverlay: KbqLoaderOverlay; |
yes |
readonly overlay = viewChild(KbqLoaderOverlay); |
yes |
readonly overlay = viewChild(\n KbqLoaderOverlay\n); |
no |
@ViewChild(\n KbqLoaderOverlay\n)\nreadonly overlay!: KbqLoaderOverlay; |
no |
The decorator form returns the instance, so the correct read is this.overlay.text() — which is exactly what collectReceivers + classifyAccess already produced (the declaration carries an explicit : KbqLoaderOverlay annotation, so it is a recognised receiver). The user reads "reading one is a double call, e.g. this.overlay().text()", edits the auto-fixed line, and gets TS2349: This expression is not callable. index.spec.ts:73-89 feeds precisely that source and asserts only the rewrite, never that the warning stays silent — so the false warning ships untested.
Meanwhile [^\n;]* cannot cross a newline, so with printWidth: 120 a real viewChild(KbqLoaderOverlay, { read: ElementRef, descendants: true }) wraps and goes unwarned — the one case the message is right about.
Restricting to the lowercase signal-query forms and allowing newlines fixes both: '(?:viewChild|contentChild)\\s*(?:\\.required)?\\s*\\([\\s\\S]{0,200}?\\bKbqLoaderOverlay\\b'.
| commit(filePath, original, content); | ||
| } | ||
|
|
||
| for (const filePath of htmlPaths) { |
There was a problem hiding this comment.
HTML files get no warning pass at all.
logWarnings and warnReceiverMembers are called only inside the tsPaths loop (lines 431-432) and both operate on the TypeScript AST, so they can never see a template read. This loop calls only migrateTemplate, which rewrites SIGNAL_MEMBERS (text, caption) and nothing else.
So a template with <kbq-loader-overlay #o /> and {{ o.isEmpty }} or [size]="o.spinnerSize" is neither rewritten nor reported:
- with
strictTemplates, the consumer's build fails withTS2445on a member the migration never mentioned; - without it,
{{ o.isEmpty }}now renders thecomputed()function's source text instead oftrue/false, because the getter became a signal.
The ref collector already resolves which refs point at an overlay — the same list could drive a PROTECTED_MEMBERS scan over the template text and emit the same per-file warning the .ts path gets. The same gap applies to inline templates.
|
|
||
| /** Whether a property access on a receiver is within one of the receiver's scopes. */ | ||
| function inReceiverScope(node: ts.PropertyAccessExpression, sourceFile: ts.SourceFile, receivers: Receiver[]): boolean { | ||
| const receiverText = node.expression.getText(sourceFile); |
There was a problem hiding this comment.
Receiver scope is text equality over the whole enclosing function, so a shadowing binding is rewritten.
collectReceivers registers a parameter's scope as the entire enclosing function (findAncestor(node, isFunctionLike)), and this compares node.expression.getText(sourceFile) against the receiver text within that span — there is no binding resolution.
render(overlay: KbqLoaderOverlay) {
this.rows.forEach((overlay) => console.log(overlay.text));
}The arrow parameter shadows the typed one, but its read sits inside the outer method's span and matches by text → rewritten to overlay.text() → TypeError on a plain row object. Same for a nested function or const of the same name.
Narrower than the template case (#3) since it needs a real name collision, but text / caption on a variable named after the component is a plausible pairing. Trimming the receiver scope when a nested declaration re-binds the same identifier would close it.
| this.parent = this.elementRef.nativeElement.parentElement; | ||
| this.parent = this.nativeElement.parentElement; | ||
|
|
||
| this.renderer.addClass(this.parent, kbqLoaderOverlayParent); |
There was a problem hiding this comment.
parent is typed HTMLElement | null and neither hook guards it.
DefaultDomRenderer2.addClass is el.classList.add(name) (@angular/platform-browser dom_renderer.mjs:617) — it throws on null, it is not a no-op.
const ref = createComponent(KbqLoaderOverlay, { environmentInjector }); ref.changeDetectorRef.detectChanges();before the host node is appended —parentElementisnull,ngOnInitthrowsTypeError: Cannot read properties of null (reading 'classList').const ref = vcr.createComponent(KbqLoaderOverlay); ref.destroy();with no CD in between —ngOnInitnever ran,parentis stillnull, andngOnDestroythrows inremoveClass.
Pre-existing, but this hook is rewritten in the diff (elementRef.nativeElement → kbqInjectNativeElement()) and the PR is billed as a full review, so it is signed off now. An early if (!this.parent) return; in both hooks covers it.
| } | ||
|
|
||
| /** Interior `[start, end]` ranges of inline `@Component({ template: '…' })` string literals. */ | ||
| function collectInlineTemplateRanges(sourceFile: ts.SourceFile): Array<{ start: number; end: number }> { |
There was a problem hiding this comment.
This is a byte-identical copy of an export the file already imports from.
packages/schematics/src/utils/typescript.ts:74 exports collectInlineTemplateRanges; diff between the two reports exactly one differing line — the export keyword. And line 7 already reads import { forEachClass, parseTemplate } from '../../utils/typescript';, where forEachClass is imported only to feed this copy (its sole use is line 322).
Seven migrations already import the shared one (app-switcher-signals, button-state-and-styles, button-supported-colors, button-toggle-signals-and-aria, dropdown-demote-overlay, list-tree-multiple-input, navbar-signals-and-aria), as does utils/icon-migration.ts:603.
Fix: change line 7 to import { collectInlineTemplateRanges, parseTemplate } from '../../utils/typescript'; and delete lines 318-353. −36 lines, zero behavior change — and the shared version documents a guarantee this copy silently won't inherit ("A template literal with a ${…} substitution is skipped").
| ### 18. Component review (20.3.0) | ||
|
|
||
| Ten components went through a full review in 20.3.0: notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. | ||
| Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. |
There was a problem hiding this comment.
This sentence contradicts three of the four subsections it introduces.
Section 18's subsections are #### Loader overlay (1052), #### Search expandable (1069), #### Split button (1091) and #### Title (1113). Search-expandable, split-button and title are all named in the first-wave list in this same sentence, yet "the second is the one each subsection below belongs to" assigns them to wave two. Loader overlay is named in neither list.
A reader migrating KbqSplitButton is told split-button was reviewed in wave one and, one clause later, that the #### Split button subsection belongs to wave two. And section 18 keeps growing — it went from 3 to 4 subsections in this PR — so every future addition either re-breaks the sentence or forces another rewrite of it. Wave membership is per-subsection data being encoded in a shared intro.
The original one-liner with loader-overlay added is self-consistent and has nothing to go stale:
| Components went through a full review in 20.3.0, in two waves. The first covered notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select; the second is the one each subsection below belongs to. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. | |
| Eleven components went through a full review in 20.3.0: loader-overlay, notification-center, popover, search-expandable, select, split-button, title, toast, tooltip, tree and tree-select. Each review closed the members that were never part of the component's contract, moved inputs to signals where that was the point of it, and fixed the behavior it uncovered along the way. Only the changes that reach a consumer are listed here. |
docs/guides/migration.ru.md:1042 carries the identical defect ("ко второй относится каждый из подразделов ниже") and needs the mirrored change.
`parent` is typed `HTMLElement | null` and went straight into `Renderer2`, which dereferences `el.classList` — a TypeError for a host with no parent element, and one the compiler cannot catch because Renderer2 accepts `any`. Both call sites are guarded now. The `@if (…; as text)` alias shadowed the input it reads from, so `text` meant two different things either side of the block boundary. The schematic never counted a template-only consumer, so the `transparent` note — the one change that actually alters what such a template renders — went unprinted. Its warning also said the content queries are `protected` when they are `private`. Tests: `size` was only ever exercised at its default, leaving the size class, the `normal` → `big` spinner mapping and the whole `card` input uncovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What
A full review of
loader-overlay, in the same shape as the 20.3.0 component reviews.textandcaptionlied about being requiredThe automated signal migration skipped both — it saw them read inside
@ifblocks and would not risk the narrowing. They areinput<string>()now and reportstring | undefined, which is what they always held. Same pathology asKbqSplitButton.disabledin the first review wave: the call sites that were already wrong now fail to compile.transparentwas backwards as a valueless attribute<kbq-loader-overlay transparent>passed the empty string, which is falsy, so the attribute rendered the filled background — the opposite of how it reads. WithbooleanAttributeit meanstrue, and[transparent]="'false'"meansfalse.Closed internals
Everything the template uses to choose between a projected slot and an input is
protectedorprivatenow:isEmpty,isExternalIndicator,isExternalText,isExternalCaption,spinnerSize, and the three content queries (which are signal queries as well). What the overlay renders is the contract, not how it decides.The three getters became
computed, soisEmptynow tracks a[text]that arrives later instead of relying on whoever happened to run change detection.Migration
loader-overlay-signalsruns fromng update @koobiq/components@20. It rewritestextandcaptionreads to calls — on receivers typedKbqLoaderOverlayand through template reference variables on<kbq-loader-overlay>, in external and inline templates — and reports the rest.size,transparentandcardwere already signals in 20.2.0 and are not touched.Documented in
docs/guides/migration.{en,ru}.md, section 18.Testing
loader-overlay.component.spec.ts: 5 → 8 tests. The new ones pin the valuelesstransparentattribute,isEmptyreacting to a late[text], and the unbound inputs reportingundefined. The existing class snapshot is unchanged.loader-overlay-signals/index.spec.ts: 13 tests — auto-fix, idempotence, receiver scoping, template refs, warnings, and the--fix=falsepath.packages/components(4996 tests) andpackages/schematics(447 tests) suites pass.check-apiis in sync.No e2e screenshots were regenerated: every e2e case binds
[transparent]="false"explicitly, so nothing it renders changed.BREAKING CHANGE
🤖 Generated with Claude Code